如果你來自 Express.js 的世界,你可能習慣了極致的自由。每個專案的目錄結構都像是一張白紙,你可以選擇 MVC、可以選擇 Domain-Driven Design,甚至可以創造自己獨特的組織方式。在建立一個新的 API 專案時,你會花費相當的時間決定:controllers 該放哪裡?models 如何組織?middleware 怎麼分類?
如果你來自 Spring Boot,你享受著 Maven 或 Gradle 帶來的標準專案結構,但同時也被大量的配置檔案和註解包圍。每個 Bean 需要明確宣告,每個依賴需要手動注入,專案結構雖然規範,但靈活性往往需要透過更多的配置來實現。
如果你來自 Python 的 FastAPI 世界,你可能欣賞它的漸進式組織方式。你可以從單一檔案開始,隨著專案成長逐步拆分成模組。FastAPI 給你建議但不強制執行,你需要自己決定是否要使用 routers、models、schemas 這樣的結構。每個團隊都可能發展出自己的「最佳實踐」,從簡單的功能分組到複雜的 Clean Architecture,選擇權完全在你手上。你可能也習慣了顯式的 import 語句,每個模組都需要明確地導入依賴,這給了你完全的控制權,但也意味著更多的樣板程式碼。
今天我們要探討的是 Rails 如何在自由與約束之間找到完美的平衡點。Rails 的專案結構不是隨意的規定,而是二十年實戰經驗的結晶。更重要的是,這個結構背後隱藏著一個強大的秘密武器:Zeitwerk,一個能讓你的程式碼「自動認識彼此」的自動載入系統。
在接下來的學習中,我們會發現 Rails 的目錄結構就像是一座精心設計的城市。每個區域都有明確的功能定位,道路(檔案命名)遵循統一的規則,而 Zeitwerk 就像是這座城市的智慧交通系統,確保所有組件都能順暢運作。這個設計將直接影響我們 LMS 系統的架構:課程模組該如何組織?權限系統該放在哪裡?背景任務如何管理?這些問題都會在今天找到答案。
Rails 的選擇:
Rails 在 2004 年誕生時,做出了一個大膽的決定:與其讓每個開發者重新發明輪子,不如提供一個經過驗證的最佳結構。這個決定在當時引起了激烈的討論,有人認為這限制了創意,有人則認為這解放了生產力。
從 Rails 1 到 Rails 7,專案結構經歷了幾次重要的演進:
與其他框架的對比:
框架 | 設計理念 | 實作方式 | 優劣權衡 |
---|---|---|---|
Rails | 約定優於配置 | 固定的目錄結構 + 自動載入 | 快速開發,但需要學習約定 |
Express | 極致自由 | 無預設結構 | 完全彈性,但容易混亂 |
Spring Boot | 註解驅動 | 標準結構 + 顯式配置 | 明確控制,但較為繁瑣 |
FastAPI | 漸進式組織 | 模組化設計 + 顯式導入 | 平衡性好,但缺乏統一標準 |
原則一:Convention over Configuration(約定優於配置)
表層理解:很多人認為這只是「減少配置檔案」的意思。
深層含義:這其實是一種「集體智慧的傳承」。每個約定背後都是無數專案的經驗總結。當你遵循 Rails 的約定時,你站在了整個社群的肩膀上。
實際影響:
# 在 Express 中,你需要明確設定路由
app.get('/courses', coursesController.index)
app.get('/courses/:id', coursesController.show)
app.post('/courses', coursesController.create)
# ... 還有更多
# 在 Rails 中,一行搞定
resources :courses
# 自動產生 7 個 RESTful 路由
# 自動對應到 CoursesController 的標準動作
原則二:Separation of Concerns(關注點分離)
常見誤解:把程式碼按類型分到不同資料夾就是關注點分離。
正確理解:Rails 的目錄結構反映的是「責任」的分離,而不只是「類型」的分離。每個目錄代表系統的一個特定關注點:
models/
- 業務邏輯和資料controllers/
- 請求協調jobs/
- 非同步處理channels/
- 即時通訊mailers/
- 郵件發送實踐指南:當你不確定程式碼該放哪裡時,問自己:「這段程式碼的主要責任是什麼?」
讓我們從零開始建立一個 Rails API 專案,深入理解每個部分的作用:
# 第一步:建立專案
# --api 標誌告訴 Rails 我們要建立 API-only 應用
# --database=postgresql 指定使用 PostgreSQL(LMS 系統的最佳選擇)
rails new learning_hub --api --database=postgresql
cd learning_hub
讓我們探索剛建立的專案結構,理解每個目錄的設計意圖:
# 專案根目錄結構解析
learning_hub/
├── app/ # 應用程式核心
│ ├── controllers/ # HTTP 請求處理器
│ │ ├── application_controller.rb # 所有控制器的基類
│ │ └── concerns/ # 共用的控制器行為
│ ├── models/ # 業務實體和邏輯
│ │ ├── application_record.rb # 所有模型的基類
│ │ └── concerns/ # 共用的模型行為
│ ├── jobs/ # 背景任務
│ │ └── application_job.rb # 所有任務的基類
│ ├── mailers/ # 郵件發送器
│ │ └── application_mailer.rb # 所有郵件程式的基類
│ └── channels/ # WebSocket 頻道(ActionCable)
│ └── application_cable/
├── config/ # 配置檔案
│ ├── routes.rb # 路由定義(URL 到控制器的映射)
│ ├── database.yml # 資料庫配置
│ ├── application.rb # 應用程式配置
│ └── environments/ # 環境特定配置
│ ├── development.rb
│ ├── test.rb
│ └── production.rb
├── db/ # 資料庫相關
│ ├── migrate/ # 資料庫遷移檔案
│ ├── schema.rb # 資料庫結構(自動生成)
│ └── seeds.rb # 種子資料
├── lib/ # 自定義程式庫
│ ├── tasks/ # 自定義 Rake 任務
│ └── assets/ # 靜態資源(API 模式通常不用)
├── public/ # 公開檔案(錯誤頁面等)
├── test/ 或 spec/ # 測試檔案
├── vendor/ # 第三方程式碼
├── Gemfile # Ruby 依賴定義
└── Gemfile.lock # 依賴版本鎖定
Zeitwerk 是 Rails 6 引入的革命性功能,讓我們深入理解它的運作方式:
# config/application.rb
module LearningHub
class Application < Rails::Application
# Rails 7 預設使用 Zeitwerk
config.load_defaults 7.1
# Zeitwerk 的神奇之處:檔案路徑 = 常數路徑
# app/models/course.rb → Course
# app/models/course/chapter.rb → Course::Chapter
# app/controllers/api/v1/courses_controller.rb → Api::V1::CoursesController
end
end
讓我們創建一個實例來理解 Zeitwerk 的威力:
# app/models/course.rb
class Course < ApplicationRecord
# Zeitwerk 會自動載入這個類別
# 不需要 require 語句
end
# app/models/course/enrollment.rb
# 巢狀類別的組織方式
class Course::Enrollment < ApplicationRecord
# 這個類別會被自動識別為 Course 的子類別
belongs_to :course
belongs_to :user
end
# app/services/course_enrollment_service.rb
# 自定義的 services 目錄也能被自動載入
class CourseEnrollmentService
def initialize(user, course)
@user = user
@course = course
end
def enroll
# 業務邏輯
# 注意:我們可以直接使用 Course::Enrollment
# 不需要任何 require 或 import
Course::Enrollment.create!(
user: @user,
course: @course,
enrolled_at: Time.current
)
end
end
Rails 允許我們在遵循約定的同時,加入自己的組織方式:
# config/application.rb
module LearningHub
class Application < Rails::Application
# 添加自定義的自動載入路徑
# 這些目錄下的檔案會被 Zeitwerk 自動載入
config.autoload_paths += %W[
#{config.root}/app/services
#{config.root}/app/serializers
#{config.root}/app/policies
#{config.root}/app/validators
]
# 設定不要自動載入的路徑(例如:只在特定時機載入)
config.autoload_once_paths += %W[
#{config.root}/app/middleware
]
end
end
現在讓我們建立這些自定義目錄並理解它們的用途:
# app/services/authentication_service.rb
# Service Objects:封裝複雜的業務邏輯
class AuthenticationService
def self.authenticate(email, password)
user = User.find_by(email: email)
return nil unless user&.authenticate(password)
# 生成 JWT token
JwtService.encode(user_id: user.id)
end
end
# app/serializers/course_serializer.rb
# Serializers:控制 API 回應的格式
class CourseSerializer
def initialize(course)
@course = course
end
def as_json
{
id: @course.id,
title: @course.title,
description: @course.description,
instructor: instructor_info,
chapters_count: @course.chapters.count,
created_at: @course.created_at.iso8601
}
end
private
def instructor_info
{
id: @course.instructor.id,
name: @course.instructor.name,
avatar_url: @course.instructor.avatar_url
}
end
end
# app/policies/course_policy.rb
# Policies:集中管理授權邏輯
class CoursePolicy
attr_reader :user, :course
def initialize(user, course)
@user = user
@course = course
end
def update?
# 只有課程擁有者或管理員可以更新
user.admin? || course.instructor == user
end
def enroll?
# 檢查是否可以註冊
!enrolled? && course.published? && !course.full?
end
private
def enrolled?
course.students.include?(user)
end
end
我們的 LMS 系統需要處理複雜的業務邏輯,良好的專案結構是成功的關鍵:
功能需求:
實作挑戰:
# 完整的 LMS 專案結構
learning_hub/
├── app/
│ ├── controllers/
│ │ ├── api/
│ │ │ ├── v1/
│ │ │ │ ├── base_controller.rb # API v1 基礎控制器
│ │ │ │ ├── courses_controller.rb # 課程管理
│ │ │ │ ├── enrollments_controller.rb # 註冊管理
│ │ │ │ ├── lessons_controller.rb # 課時管理
│ │ │ │ └── assignments_controller.rb # 作業管理
│ │ │ └── v2/ # 未來的 API 版本
│ │ └── concerns/
│ │ ├── authenticatable.rb # 認證相關
│ │ └── error_handler.rb # 錯誤處理
│ ├── models/
│ │ ├── user.rb # 使用者模型
│ │ ├── course.rb # 課程模型
│ │ ├── course/ # 課程相關的子模型
│ │ │ ├── chapter.rb # 章節
│ │ │ ├── lesson.rb # 課時
│ │ │ └── enrollment.rb # 註冊記錄
│ │ ├── assignment/ # 作業相關
│ │ │ ├── submission.rb # 提交記錄
│ │ │ └── review.rb # 批改記錄
│ │ └── concerns/
│ │ ├── trackable.rb # 追蹤行為
│ │ └── publishable.rb # 發布功能
│ ├── services/ # 業務邏輯服務
│ │ ├── enrollment_service.rb # 註冊服務
│ │ ├── grading_service.rb # 評分服務
│ │ ├── notification_service.rb # 通知服務
│ │ └── video_processing_service.rb # 影片處理
│ ├── jobs/ # 背景任務
│ │ ├── video_transcode_job.rb # 影片轉碼
│ │ ├── certificate_generation_job.rb # 證書生成
│ │ └── email_notification_job.rb # 郵件通知
│ ├── serializers/ # API 序列化
│ │ ├── course_serializer.rb
│ │ ├── user_serializer.rb
│ │ └── lesson_progress_serializer.rb
│ ├── policies/ # 授權策略
│ │ ├── course_policy.rb
│ │ ├── assignment_policy.rb
│ │ └── admin_policy.rb
│ └── validators/ # 自定義驗證器
│ ├── email_validator.rb
│ └── video_format_validator.rb
讓我們實作 LMS 的核心結構:
# app/models/course.rb
module LMS
class Course < ApplicationRecord
# 關聯定義:展現課程的結構
has_many :chapters, -> { order(:position) },
dependent: :destroy,
class_name: 'Course::Chapter'
has_many :lessons, through: :chapters
has_many :enrollments,
class_name: 'Course::Enrollment',
dependent: :destroy
has_many :students, through: :enrollments,
source: :user
belongs_to :instructor, class_name: 'User'
# 引入共用行為
include Publishable # 提供 publish!, unpublish! 等方法
include Trackable # 提供進度追蹤功能
# 業務邏輯方法
def enroll_student(user)
# 使用 Service Object 處理複雜邏輯
EnrollmentService.new(user, self).execute
end
def completion_rate_for(user)
# 計算使用者的課程完成率
total_lessons = lessons.count
return 0 if total_lessons.zero?
completed_lessons = lessons
.joins(:progress_records)
.where(progress_records: {
user_id: user.id,
completed: true
})
.count
(completed_lessons.to_f / total_lessons * 100).round(2)
end
end
end
# app/models/concerns/publishable.rb
# Concern:可重用的模組化行為
module Publishable
extend ActiveSupport::Concern
included do
# 加入到包含此模組的類別中
scope :published, -> { where(published: true) }
scope :draft, -> { where(published: false) }
# 狀態機或簡單的狀態管理
enum status: {
draft: 0,
published: 1,
archived: 2
}
end
# 實例方法
def publish!
return if published?
transaction do
self.published = true
self.published_at = Time.current
save!
# 觸發相關的背景任務
CoursePublishedJob.perform_later(self)
end
end
def unpublish!
update!(published: false, published_at: nil)
end
# 類別方法
class_methods do
def publish_all!
draft.find_each(&:publish!)
end
end
end
# app/services/enrollment_service.rb
# Service Object:封裝複雜的業務邏輯
class EnrollmentService
class EnrollmentError < StandardError; end
def initialize(user, course)
@user = user
@course = course
end
def execute
validate_enrollment!
ActiveRecord::Base.transaction do
# 建立註冊記錄
enrollment = create_enrollment
# 初始化學習進度
initialize_progress
# 發送通知
send_notifications
# 更新統計
update_statistics
enrollment
end
rescue ActiveRecord::RecordInvalid => e
raise EnrollmentError, "註冊失敗:#{e.message}"
end
private
def validate_enrollment!
raise EnrollmentError, "課程未發布" unless @course.published?
raise EnrollmentError, "已經註冊過此課程" if already_enrolled?
raise EnrollmentError, "課程已額滿" if @course.full?
raise EnrollmentError, "權限不足" unless can_enroll?
end
def already_enrolled?
@course.enrollments.exists?(user: @user)
end
def can_enroll?
CoursePolicy.new(@user, @course).enroll?
end
def create_enrollment
Course::Enrollment.create!(
user: @user,
course: @course,
enrolled_at: Time.current,
status: 'active'
)
end
def initialize_progress
@course.lessons.find_each do |lesson|
LessonProgress.create!(
user: @user,
lesson: lesson,
status: 'not_started'
)
end
end
def send_notifications
# 非同步發送通知
EnrollmentNotificationJob.perform_later(@user, @course)
end
def update_statistics
# 更新課程統計資料
@course.increment!(:enrollments_count)
@user.increment!(:enrolled_courses_count)
end
end
使用 Mermaid 展示在系統架構中的位置:
%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '16px'}}}%%
graph TB
subgraph "LMS 系統架構"
A[專案結構設計]
B[Controllers 層]
C[Models 層]
D[Services 層]
E[資料庫層]
A --> B
A --> C
A --> D
B --> D
C --> E
D --> C
D --> E
end
style A fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
style B fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
style C fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
style D fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
style E fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#000
誤區 1:試圖重新組織 Rails 的目錄結構
錯誤表現:
# 錯誤:試圖建立 Express 風格的結構
app/
├── src/
│ ├── routes/
│ ├── middlewares/
│ └── utils/
根本原因:習慣了其他框架的組織方式,想要保持熟悉感。
正確做法:擁抱 Rails 的約定,在約定的基礎上擴展:
# 正確:遵循 Rails 約定,必要時添加自定義目錄
app/
├── controllers/ # Rails 標準
├── models/ # Rails 標準
├── services/ # 自定義:業務邏輯
├── presenters/ # 自定義:展示邏輯
思維轉換:Rails 的目錄結構是經過千錘百煉的最佳實踐,先理解和遵循,再根據需要調整。
誤區 2:過度使用 Concerns
錯誤表現:
# 錯誤:為了「DRY」而過度抽象
module Timestampable
extend ActiveSupport::Concern
included do
# 只是為了共用兩個欄位...
scope :recent, -> { order(created_at: :desc) }
end
end
根本原因:誤解了 DRY 原則,認為任何重複都應該被消除。
正確做法:
# 正確:只在有明確業務含義時使用 Concern
module Enrollable
extend ActiveSupport::Concern
included do
has_many :enrollments, as: :enrollable
has_many :enrolled_users, through: :enrollments, source: :user
# 包含完整的業務邏輯
def enroll(user)
# ...
end
def unenroll(user)
# ...
end
def enrolled?(user)
# ...
end
end
end
自動載入的效能影響:
# 開發環境:Zeitwerk 會在每次請求時檢查檔案變更
# 這提供了良好的開發體驗,但會有些許效能開銷
# config/environments/development.rb
Rails.application.configure do
# 開啟程式碼重新載入
config.cache_classes = false
config.eager_load = false
end
# 生產環境:所有類別都會被預先載入
# config/environments/production.rb
Rails.application.configure do
# 關閉程式碼重新載入,提升效能
config.cache_classes = true
config.eager_load = true
end
優化策略:
# 避免將大量檔案加入自動載入路徑
# 錯誤
config.autoload_paths += Dir["#{config.root}/lib/**/*"]
# 正確:只加入需要的目錄
config.autoload_paths << "#{config.root}/lib/validators"
# 將關鍵路徑加入預先載入
config.eager_load_paths << "#{config.root}/app/services"
# spec/services/enrollment_service_spec.rb
RSpec.describe EnrollmentService do
let(:user) { create(:user) }
let(:course) { create(:course, :published) }
let(:service) { described_class.new(user, course) }
describe '#execute' do
context '當滿足所有條件時' do
it '成功建立註冊記錄' do
expect {
service.execute
}.to change { Course::Enrollment.count }.by(1)
end
it '初始化學習進度' do
service.execute
expect(user.lesson_progresses.count).to eq(course.lessons.count)
end
it '發送通知任務' do
expect {
service.execute
}.to have_enqueued_job(EnrollmentNotificationJob)
end
end
context '當課程未發布時' do
let(:course) { create(:course, :draft) }
it '拋出錯誤' do
expect {
service.execute
}.to raise_error(EnrollmentService::EnrollmentError, /課程未發布/)
end
end
end
end
練習目標:
熟悉 Rails 專案結構和 Zeitwerk 自動載入機制
練習內容:
建立一個簡單的書店 API,實踐今天學到的專案組織概念。
# 建立新的 Rails API 專案
rails new bookstore_api --api --database=postgresql
cd bookstore_api
# 建立資料庫
rails db:create
# 產生 Book 模型
rails generate model Book title:string author:string isbn:string price:decimal description:text
rails db:migrate
首先,我們需要告訴 Rails 關於自定義目錄:
# config/application.rb
module BookstoreApi
class Application < Rails::Application
config.load_defaults 7.1
config.api_only = true
# 添加自定義目錄到自動載入路徑
config.autoload_paths += %W[
#{config.root}/app/services
#{config.root}/app/serializers
]
end
end
# app/models/book.rb
class Book < ApplicationRecord
# 加入 Paginatable concern
include Paginatable
# 驗證規則
validates :title, presence: true
validates :author, presence: true
validates :isbn, presence: true, uniqueness: true
validates :price, numericality: { greater_than_or_equal_to: 0 }
# 搜尋範圍
scope :by_author, ->(author) { where("author ILIKE ?", "%#{author}%") }
scope :by_title, ->(title) { where("title ILIKE ?", "%#{title}%") }
end
# app/models/concerns/paginatable.rb
module Paginatable
extend ActiveSupport::Concern
included do
# 每頁預設筆數
DEFAULT_PER_PAGE = 20
MAX_PER_PAGE = 100
# 分頁 scope
scope :paginate, ->(page: 1, per_page: DEFAULT_PER_PAGE) do
# 確保參數在合理範圍內
page = [page.to_i, 1].max
per_page = [[per_page.to_i, MAX_PER_PAGE].min, 1].max
# 計算 offset 並返回結果
offset = (page - 1) * per_page
limit(per_page).offset(offset)
end
end
# 類別方法
class_methods do
# 計算總頁數
def total_pages(per_page = DEFAULT_PER_PAGE)
(count.to_f / per_page).ceil
end
# 取得分頁資訊
def pagination_info(page: 1, per_page: DEFAULT_PER_PAGE)
{
current_page: page,
per_page: per_page,
total_count: count,
total_pages: total_pages(per_page)
}
end
end
end
# app/serializers/book_serializer.rb
class BookSerializer
def initialize(book)
@book = book
end
def as_json
{
id: @book.id,
title: @book.title,
author: @book.author,
isbn: @book.isbn,
price: format_price(@book.price),
description: @book.description,
created_at: @book.created_at.iso8601,
updated_at: @book.updated_at.iso8601
}
end
# 序列化集合
def self.collection(books)
books.map { |book| new(book).as_json }
end
private
def format_price(price)
return nil if price.nil?
"$%.2f" % price
end
end
# app/services/book_search_service.rb
class BookSearchService
def initialize(params = {})
@query = params[:query]
@author = params[:author]
@title = params[:title]
@page = params[:page] || 1
@per_page = params[:per_page] || 20
end
def search
books = Book.all
# 全文搜尋(搜尋標題、作者、ISBN)
if @query.present?
books = books.where(
"title ILIKE :query OR author ILIKE :query OR isbn ILIKE :query",
query: "%#{@query}%"
)
end
# 依作者搜尋
books = books.by_author(@author) if @author.present?
# 依標題搜尋
books = books.by_title(@title) if @title.present?
# 分頁
paginated_books = books.paginate(page: @page, per_page: @per_page)
# 返回結果和分頁資訊
{
books: paginated_books,
pagination: books.pagination_info(page: @page, per_page: @per_page)
}
end
# 進階搜尋功能:支援多個條件組合
def advanced_search(filters = {})
books = Book.all
# 價格範圍
if filters[:min_price].present?
books = books.where("price >= ?", filters[:min_price])
end
if filters[:max_price].present?
books = books.where("price <= ?", filters[:max_price])
end
# 出版日期範圍
if filters[:published_after].present?
books = books.where("created_at >= ?", filters[:published_after])
end
if filters[:published_before].present?
books = books.where("created_at <= ?", filters[:published_before])
end
# 組合基本搜尋
basic_result = search
# 合併進階條件的結果
{
books: books.merge(basic_result[:books]),
pagination: basic_result[:pagination]
}
end
end
# app/controllers/api/v1/books_controller.rb
module Api
module V1
class BooksController < ApplicationController
def index
# 使用 Service 來處理搜尋邏輯
search_service = BookSearchService.new(search_params)
result = search_service.search
# 使用 Serializer 來格式化輸出
render json: {
books: BookSerializer.collection(result[:books]),
pagination: result[:pagination]
}
end
def show
book = Book.find(params[:id])
render json: BookSerializer.new(book).as_json
rescue ActiveRecord::RecordNotFound
render json: { error: "Book not found" }, status: :not_found
end
private
def search_params
params.permit(:query, :author, :title, :page, :per_page)
end
end
end
end
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :books, only: [:index, :show]
end
end
end
# 在 Rails console 中測試
rails console
# 建立測試資料
Book.create!(
title: "The Rails 7 Way",
author: "Obie Fernandez",
isbn: "978-0134657677",
price: 49.99,
description: "The comprehensive guide to Rails 7"
)
Book.create!(
title: "Agile Web Development with Rails 7",
author: "Sam Ruby",
isbn: "978-1680509298",
price: 55.00,
description: "Learn Rails the Agile way"
)
# 測試 Serializer
book = Book.first
serializer = BookSerializer.new(book)
puts serializer.as_json
# 應該看到格式化的 JSON 輸出
# 測試 Search Service
service = BookSearchService.new(query: "Rails")
results = service.search
puts results[:books].count
# 應該返回 2 筆結果
# 測試分頁
service = BookSearchService.new(page: 1, per_page: 1)
results = service.search
puts results[:pagination]
# 應該看到分頁資訊
# 測試 Concern
Book.paginate(page: 1, per_page: 10)
# 應該返回分頁結果
挑戰目標:
為 LMS 系統設計並實作完整的專案結構
rails new lms_api --api --database=postgresql
cd lms_api
rails db:create
# config/application.rb
module LmsApi
class Application < Rails::Application
config.load_defaults 7.1
config.api_only = true
# 自定義目錄
config.autoload_paths += %W[
#{config.root}/app/services
#{config.root}/app/serializers
#{config.root}/app/policies
]
end
end
# 建立 User 模型
rails g model User email:string name:string role:integer password_digest:string
# 建立 Course 模型
rails g model Course title:string description:text instructor_id:integer max_students:integer status:integer
# 建立 Enrollment 模型(注意:這是關聯表,但包含額外欄位)
rails g model Enrollment user:references course:references enrolled_at:datetime completed_at:datetime progress:integer status:integer
rails db:migrate
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
# 角色定義
enum role: {
student: 0,
instructor: 1,
admin: 2
}
# 關聯
has_many :enrollments, dependent: :destroy
has_many :enrolled_courses, through: :enrollments, source: :course
has_many :teaching_courses, class_name: 'Course', foreign_key: 'instructor_id'
# 驗證
validates :email, presence: true, uniqueness: true
validates :name, presence: true
# 商業邏輯方法
def enrolled_in?(course)
enrollments.exists?(course: course)
end
def can_teach?(course)
instructor? && course.instructor_id == id
end
end
# app/models/course.rb
class Course < ApplicationRecord
# 狀態定義
enum status: {
draft: 0,
published: 1,
archived: 2
}
# 關聯
belongs_to :instructor, class_name: 'User'
has_many :enrollments, dependent: :destroy
has_many :students, through: :enrollments, source: :user
# 驗證
validates :title, presence: true
validates :description, presence: true
validates :max_students, numericality: { greater_than: 0 }
# 範圍查詢
scope :available, -> { published.where('max_students > ?', 0) }
scope :by_instructor, ->(instructor) { where(instructor: instructor) }
# 商業邏輯
def full?
return false if max_students.nil?
enrollments.active.count >= max_students
end
def available_spots
return nil if max_students.nil?
max_students - enrollments.active.count
end
def enrollment_rate
return 0 if max_students.nil? || max_students.zero?
(enrollments.active.count.to_f / max_students * 100).round(2)
end
end
# app/models/enrollment.rb
class Enrollment < ApplicationRecord
# 狀態定義
enum status: {
active: 0,
completed: 1,
dropped: 2,
suspended: 3
}
# 關聯
belongs_to :user
belongs_to :course
# 驗證
validates :user_id, uniqueness: { scope: :course_id, message: "已經註冊過此課程" }
validates :progress, numericality: {
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100
}, allow_nil: true
# 回調
before_create :set_enrolled_at
# 範圍查詢
scope :active, -> { where(status: :active) }
scope :recent, -> { order(enrolled_at: :desc) }
private
def set_enrolled_at
self.enrolled_at ||= Time.current
end
end
# app/services/enrollment_service.rb
class EnrollmentService
class EnrollmentError < StandardError; end
attr_reader :user, :course, :errors
def initialize(user, course)
@user = user
@course = course
@errors = []
end
def execute
validate_enrollment!
ActiveRecord::Base.transaction do
enrollment = create_enrollment
update_course_statistics
send_confirmation_email
{ success: true, enrollment: enrollment }
end
rescue EnrollmentError => e
{ success: false, error: e.message, errors: @errors }
rescue ActiveRecord::RecordInvalid => e
{ success: false, error: "註冊失敗", errors: e.record.errors.full_messages }
end
private
def validate_enrollment!
# 收集所有錯誤,提供完整的錯誤訊息
@errors << "課程尚未發布" unless @course.published?
@errors << "您已經註冊過此課程" if @user.enrolled_in?(@course)
@errors << "課程已額滿" if @course.full?
@errors << "您沒有權限註冊此課程" unless can_enroll?
raise EnrollmentError, @errors.join(", ") if @errors.any?
end
def can_enroll?
# 使用 Policy 來判斷權限
CoursePolicy.new(@user, @course).enroll?
end
def create_enrollment
Enrollment.create!(
user: @user,
course: @course,
status: :active,
progress: 0
)
end
def update_course_statistics
# 這裡可以更新快取的統計資料
# 例如:更新 Redis 中的註冊人數
Rails.logger.info "Updated enrollment count for course #{@course.id}"
end
def send_confirmation_email
# 在實際應用中,這會觸發背景任務
# EnrollmentMailer.confirmation(@user, @course).deliver_later
Rails.logger.info "Enrollment confirmation email queued for #{@user.email}"
end
end
# app/services/enrollment_cancellation_service.rb
class EnrollmentCancellationService
attr_reader :enrollment, :reason
def initialize(enrollment, reason: nil)
@enrollment = enrollment
@reason = reason
end
def execute
return { success: false, error: "註冊記錄不存在" } unless @enrollment
return { success: false, error: "此註冊已經取消" } unless @enrollment.active?
ActiveRecord::Base.transaction do
@enrollment.update!(
status: :dropped,
completed_at: Time.current
)
log_cancellation
notify_instructor
{ success: true, message: "成功取消註冊" }
end
rescue => e
{ success: false, error: e.message }
end
private
def log_cancellation
Rails.logger.info "Enrollment #{@enrollment.id} cancelled. Reason: #{@reason}"
end
def notify_instructor
# 通知講師有學生退選
Rails.logger.info "Instructor notified about cancellation"
end
end
# app/policies/course_policy.rb
class CoursePolicy
attr_reader :user, :course
def initialize(user, course)
@user = user
@course = course
end
# 查看課程
def show?
# 已發布的課程所有人都可以看
# 草稿只有講師和管理員可以看
@course.published? || @user.admin? || @user.can_teach?(@course)
end
# 更新課程
def update?
@user.admin? || @user.can_teach?(@course)
end
# 刪除課程
def destroy?
@user.admin?
end
# 註冊課程
def enroll?
# 學生可以註冊
# 講師不能註冊自己的課程
# 管理員可以註冊任何課程(用於測試)
return false unless @course.published?
return false if @course.full?
return true if @user.admin?
@user.student? && !@user.can_teach?(@course) && !@user.enrolled_in?(@course)
end
# 管理註冊(查看學生名單等)
def manage_enrollments?
@user.admin? || @user.can_teach?(@course)
end
end
# app/policies/enrollment_policy.rb
class EnrollmentPolicy
attr_reader :user, :enrollment
def initialize(user, enrollment)
@user = user
@enrollment = enrollment
end
# 查看註冊詳情
def show?
# 本人、講師、管理員可以查看
@enrollment.user == @user ||
@user.can_teach?(@enrollment.course) ||
@user.admin?
end
# 更新進度
def update_progress?
# 只有講師和管理員可以更新進度
@user.can_teach?(@enrollment.course) || @user.admin?
end
# 取消註冊
def cancel?
# 本人可以取消,管理員也可以
@enrollment.user == @user || @user.admin?
end
end
# app/serializers/course_serializer.rb
class CourseSerializer
def initialize(course, current_user: nil)
@course = course
@current_user = current_user
end
def as_json
base_attributes.merge(conditional_attributes)
end
def self.collection(courses, current_user: nil)
courses.map { |course| new(course, current_user: current_user).as_json }
end
private
def base_attributes
{
id: @course.id,
title: @course.title,
description: @course.description,
status: @course.status,
instructor: instructor_info,
max_students: @course.max_students,
available_spots: @course.available_spots,
enrollment_rate: @course.enrollment_rate,
created_at: @course.created_at.iso8601
}
end
def conditional_attributes
attrs = {}
# 如果當前使用者已註冊,顯示註冊資訊
if @current_user && @current_user.enrolled_in?(@course)
enrollment = @current_user.enrollments.find_by(course: @course)
attrs[:enrollment] = {
enrolled_at: enrollment.enrolled_at.iso8601,
progress: enrollment.progress,
status: enrollment.status
}
end
# 如果是講師或管理員,顯示額外資訊
if @current_user && can_manage?
attrs[:students_count] = @course.enrollments.active.count
attrs[:completion_rate] = calculate_completion_rate
end
attrs
end
def instructor_info
{
id: @course.instructor.id,
name: @course.instructor.name,
email: @course.instructor.email
}
end
def can_manage?
@current_user.admin? || @current_user.can_teach?(@course)
end
def calculate_completion_rate
total = @course.enrollments.count
return 0 if total.zero?
completed = @course.enrollments.completed.count
(completed.to_f / total * 100).round(2)
end
end
# app/serializers/enrollment_serializer.rb
class EnrollmentSerializer
def initialize(enrollment)
@enrollment = enrollment
end
def as_json
{
id: @enrollment.id,
user: user_info,
course: course_info,
enrolled_at: @enrollment.enrolled_at.iso8601,
completed_at: @enrollment.completed_at&.iso8601,
progress: @enrollment.progress,
status: @enrollment.status
}
end
private
def user_info
{
id: @enrollment.user.id,
name: @enrollment.user.name,
email: @enrollment.user.email
}
end
def course_info
{
id: @enrollment.course.id,
title: @enrollment.course.title
}
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include ErrorHandler
before_action :authenticate_user!
private
def authenticate_user!
# 簡化版的認證,實際應使用 JWT
@current_user = User.find_by(id: request.headers['User-Id'])
render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user
end
def current_user
@current_user
end
end
# app/controllers/concerns/error_handler.rb
module ErrorHandler
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: e.message }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
rescue_from StandardError do |e|
Rails.logger.error "Unexpected error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: { error: '內部伺服器錯誤' }, status: :internal_server_error
end
end
end
# app/controllers/api/v1/courses_controller.rb
module Api
module V1
class CoursesController < ApplicationController
skip_before_action :authenticate_user!, only: [:index, :show]
def index
courses = Course.published
render json: {
courses: CourseSerializer.collection(courses, current_user: current_user)
}
end
def show
course = Course.find(params[:id])
# 使用 Policy 檢查權限
unless CoursePolicy.new(current_user, course).show?
render json: { error: '您沒有權限查看此課程' }, status: :forbidden
return
end
render json: CourseSerializer.new(course, current_user: current_user).as_json
end
end
end
end
# app/controllers/api/v1/enrollments_controller.rb
module Api
module V1
class EnrollmentsController < ApplicationController
def create
course = Course.find(params[:course_id])
service = EnrollmentService.new(current_user, course)
result = service.execute
if result[:success]
render json: {
message: '註冊成功',
enrollment: EnrollmentSerializer.new(result[:enrollment]).as_json
}, status: :created
else
render json: {
error: result[:error],
errors: result[:errors]
}, status: :unprocessable_entity
end
end
def destroy
enrollment = current_user.enrollments.find(params[:id])
# 檢查權限
unless EnrollmentPolicy.new(current_user, enrollment).cancel?
render json: { error: '您沒有權限取消此註冊' }, status: :forbidden
return
end
service = EnrollmentCancellationService.new(enrollment)
result = service.execute
if result[:success]
render json: { message: result[:message] }
else
render json: { error: result[:error] }, status: :unprocessable_entity
end
end
end
end
end
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :courses, only: [:index, :show] do
resources :enrollments, only: [:create]
end
resources :enrollments, only: [:destroy]
end
end
end
# 在 Rails console 中測試
rails console
# 建立測試資料
instructor = User.create!(
email: "instructor@example.com",
name: "Dr. Smith",
password: "password",
role: :instructor
)
student = User.create!(
email: "student@example.com",
name: "John Doe",
password: "password",
role: :student
)
course = Course.create!(
title: "Ruby on Rails 實戰",
description: "深入學習 Rails 開發",
instructor: instructor,
max_students: 30,
status: :published
)
# 測試註冊服務
service = EnrollmentService.new(student, course)
result = service.execute
puts result
# 應該看到 { success: true, enrollment: ... }
# 測試 Policy
policy = CoursePolicy.new(student, course)
puts policy.enroll?
# 應該返回 false(已經註冊過了)
# 測試 Serializer
serializer = CourseSerializer.new(course, current_user: student)
puts serializer.as_json
# 應該包含註冊資訊
# 測試取消註冊
enrollment = student.enrollments.first
cancel_service = EnrollmentCancellationService.new(enrollment)
result = cancel_service.execute
puts result
# 應該看到成功取消的訊息
問題 1:Zeitwerk 找不到類別
如果遇到 uninitialized constant
錯誤,檢查:
autoload_paths
問題 2:Service Object 的測試
# spec/services/enrollment_service_spec.rb
RSpec.describe EnrollmentService do
let(:student) { create(:user, :student) }
let(:course) { create(:course, :published, max_students: 2) }
describe '#execute' do
context '成功註冊' do
it '建立註冊記錄' do
service = described_class.new(student, course)
result = service.execute
expect(result[:success]).to be true
expect(result[:enrollment]).to be_persisted
expect(student.enrolled_in?(course)).to be true
end
end
context '驗證失敗' do
it '當課程已額滿時' do
# 先讓其他學生註冊滿
create_list(:enrollment, 2, course: course)
service = described_class.new(student, course)
result = service.execute
expect(result[:success]).to be false
expect(result[:errors]).to include("課程已額滿")
end
end
end
end
這樣完整的練習和解答能讓學習者真正動手實作,並理解每個部分的設計理念。透過基礎練習熟悉概念,再透過進階挑戰整合所有知識,形成完整的學習循環。
與前期內容的連結:
對後續內容的鋪墊:
%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '14px'}}}%%
graph LR
subgraph "學習脈絡"
Day1[Day 1: Ruby 語法基礎]
Day2[Day 2: 專案結構與哲學]
Day3[Day 3: MVC 架構]
LMS[LMS 系統架構設計]
Day1 -->|語言特性| Day2
Day2 -->|結構基礎| Day3
Day3 -->|架構理解| LMS
end
style Day2 fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
style Day1 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
style Day3 fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
style LMS fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
知識層面:
思維層面:
實踐層面:
完成今天的學習後,你應該能夠:
深入閱讀:
相關 Gem:
dry-system
:提供更靈活的依賴注入和組件管理packwerk
:Shopify 開發的模組化工具,適合大型應用明天我們將探討 MVC 架構在 API 模式下的實踐。如果說今天學習的是城市的規劃藍圖,那明天就是理解這座城市中不同區域如何協同運作。我們會深入控制器如何協調請求、模型如何封裝業務邏輯,以及在沒有傳統 View 的情況下,API 如何優雅地回應客戶端。
準備好了嗎?讓我們繼續深入 Rails 的架構之美。